Análise de Texto

Como terceirizar seu trabalho

Carolina Musso
João Pedro
Rafael de Acypreste
Vítor Borges

Nesta apresentação

  • Processamento de texto
  • Web Scraping
  • Usando a API da OpenAI
  • Embedings
  • Clusterização
  • Protótipo
  • Para o Futuro…

Processamento de Texto e IA

  • Quantidade imensa de informações disponível: textos, na web, em documentos digitais, ou em bases de dados.
    • Extrair significados e insights valiosos.
    • Melhorar a comunicação humana (correção, melhora e acessibilidade).
    • Automatização e Eficiência

Evolução dos Modelos de IA

Redes Neurais:

  • Maior precisão e capacidade de lidar com tarefas complexas como na compreensão de contexto e geração de texto natural.
    • LSTM (Long Short-Term Memory), RNNs (Redes Neurais Recorrentes), Transformadores (revolucionou o processamento de texto)
    • Modelos de Linguagem Avançados: GPT-3 e GPT-4: Geração de conteúdo, tradução automática, síntese de texto.

Desafio com Ementas Existentes

  • Baixar uma a uma! -> Web Scraping para Coleta de Dados.
  • Cada uma diferente da outra…
  • Uso de modelos de IA, como o ChatGPT, para padronizar as ementas.
  • Objetivo: Criar resumos claros e concisos de cada disciplina.
    • Gerar maior acessibilidade e compreensão das informações das ementas.
    • Simplificar análise para visualizção de similaridades entre disciplinas/departamentos.

Web scraping

A saga das ementas

Web Scraping

  • Pacote R webdriver

  • Um cliente para a ‘API WebDriver’: controlando um navegador da web. Funciona com qualquer implementação de ‘WebDriver’, mas foi testado apenas com ‘PhantomJS’.

  • Selenium é uma ferramenta de código aberto popular para automação de navegadores web, e utiliza a API WebDriver, uma interface padrão para controle de navegadores, que também é acessível em R por meio do pacote “webdriver”, possibilitando a automação de navegadores para testes e raspagem de dados na web.

Código

library(webdriver)

## Define Main URL
url <- "https://sigaa.unb.br/sigaa/public/componentes/busca_componentes.jsf"

## Init Session and start scraping by query type
init_session <- function(type_of_query,url){
  # Init Library, Session and Navigate to URL
  pjs <<- run_phantomjs()
  s <<- Session$new(port = pjs$port)
  s$go(url)
  
  # First Search Boxes
  
  ## Select "Graduação"
  search_nivel <- s$findElement(css = "option[value='G']")
  search_nivel$click()

    ## Select "Disciplinas"
  search_tipo <- s$findElement(xpath = '//select[@id="form:tipo"]//option[@value="2"]')
  search_tipo$click()
  
  # Search only in the following 'unidades' according to `type_of_query`
  ## - DEPARTAMENTO
  ## - DEPTO
  ## - FACULDADE
  ## - INSTITUTO   
  switch(type_of_query,
         ## Select "Departamentos"
         departamentos = {
           query <- s$findElements(xpath = '//select[@id="form:unidades"]//option[starts-with(text(),"DEPARTAMENTO") or starts-with(text(),"DEPTO")]')},
        ## Select "Faculdades"
         faculdades = {
           query <- s$findElements(xpath = '//select[@id="form:unidades"]//option[starts-with(text(),"FACULDADE")]')},
        ## Select "Institutos"
        institutos = {
          query <- s$findElements(xpath = '//select[@id="form:unidades"]//option[starts-with(text(),"INSTITUTO") ]')}
         )
  return(query)
}

## Functions 
iterate_unit_list <- function(unit_list){
   if(!is.list(unit_list)) {
    message("Argument is not a list")
    return()
   }
  message( glue::glue("Process started at {Sys.time()} "))
  total_size <- length(unit_list)
  # DEFINE SCRAPING RANGE HERE  
  #
  # - uncomment the next line, change to desired range
  # - comment the other for, or use it uncommented to get ALL data at once.
  #
  for(i in c(43:44)){
    # Submit full search criteria
    browser()
    message( glue::glue("Scanning ",unit_list[[i]]$getText()," [{i}/{total_size}]"))
    unit_list[[i]]$click()
    submit_search <- s$findElement(xpath = '//input[@id="form:btnBuscarComponentes"]')
    submit_search$click()
    
    ## Create the index and start scanning if not empty.
    details_list <- s$findElements(xpath = "//a[contains(@title, 'Detalhes')]" )
    if(length(details_list) == 0){
      message("No subjects found: Continuing with next unit... ")
      # Rebuild Index
      unit_list <- s$findElements(xpath = '//select[@id="form:unidades"]//option[starts-with(text(),"DEPARTAMENTO") or starts-with(text(),"DEPTO")]')
      next
    }
    content <- get_subject_content(details_list)
    
    # Write this Unit CSV to persist data
    export_content(content)
    
    # Rebuild Index
    unit_list <- s$findElements(xpath = '//select[@id="form:unidades"]//option[starts-with(text(),"DEPARTAMENTO") or starts-with(text(),"DEPTO")]')
  }
  message(glue::glue("Process Completed at {Sys.time()}"))
  return()
}

get_subject_content <- function(subject_list){
  if(!is.list(subject_list)) {
    message("Argument is not a list")
    return()
  }
  total_size <- length(subject_list) 
  tables <- tibble::tibble()
  for(i in 1:length(subject_list)){
    # Go to detail page
    subject_list[[i]]$click()
    
    # Grab its table in a nice format
    ## The '25' is a khabalistic-pseudo-safe number that I suppose will be safe enough
    ## to get any number of other non-standard fields and still get the last one with the "Ementa"
    table <- s$findElements(xpath = "//table[@class='visualizacao']/tbody/tr[position() <= 25]/td[not(table)]/..") 
    details <- table |>
    purrr::reduce(\(acc,e){ paste0(acc, e$executeScript("return arguments[0].outerHTML")) }, .init = "<table>") |> 
    append("</table>") |>
    paste0(collapse="") |>
    rvest::read_html() |>
    rvest::html_table() |>
    purrr::pluck(1) |> 
    dplyr::filter(
      stringr::str_starts(X1,"Tipo do Componente") | 
      stringr::str_starts(X1,"Modalidade") |
      stringr::str_starts(X1,"Unidade") |
      stringr::str_starts(X1,"Código") |
      stringr::str_starts(X1,"Nome") |
      stringr::str_starts(X1,"Pré-Requisitos") |
      stringr::str_starts(X1,"Co-Requisitos") |
      stringr::str_starts(X1,"Equivalências") |
      stringr::str_starts(X1,"Ementa") 
    ) |>   
    tidyr::pivot_wider(names_from=X1, values_from=X2, values_fill=NA)
    message("Details ✅")
    
    # Grab Workload
    find_workload <- \(path){s$findElement(xpath = path)$getText()}
    possibly_find_workload <- purrr::possibly(find_workload, otherwise = "Subtotal de Carga Horária de Aula - Presencial \n0h")
    
    workload <- possibly_find_workload("//table[@class='visualizacao']//td[b[contains(text(),'Subtotal de Carga Horária de Aula - Presencial')]]/following-sibling::td/..") 
    workload_df <- workload |>
     stringr::str_split("\n", simplify = TRUE) |> 
     (\(.workload){ tibble::tibble( "{.workload[1]}" := .workload[2])})()
    
    # Concatenate Details and Workload
    details_df <- tibble::add_column(details, workload_df)
    message("Workload ✅")
    
    ##### Go back to index page ####
    back <- s$findElement(xpath = "//a[text()=' << Voltar ']")
    back$click()
    
    # Try to get detailed program
    program_list <- s$findElements(xpath = "//a[contains(@title, 'Programa')]" )
    
    tryCatch(
     {
       program_list[[i]]$click()
       program <- s$findElements(css = ".itemPrograma")
     }, 
    
      # If fails, close error modal and rebuild index
     error = function(e) { 
       message("No program available. Going Back...")
       error <- s$findElement(css = "#fechar-painel-erros > a")
       error$click()
     },
     finally = program_list <- s$findElements(xpath = "//a[contains(@title, 'Programa')]" )
     )
    
    # If succeeds, process program data and go back to main page.
    if(length(program) > 0) {
      program_df <- tibble::tibble(Objetivos = program[[1]]$getText(), "Conteúdo" = program[[2]]$getText())
      s$goBack()
    } else {
      program_df <- tibble::tibble(Objetivos = NA, "Conteúdo" = NA)
    }
    message("Program ✅")
    
    # Recreate the index 
    subject_list <- s$findElements(xpath = "//a[contains(@title, 'Detalhes')]" )
    program_list <- s$findElements(xpath = "//a[contains(@title, 'Programa')]" )
    
    # Concatenate to final structure
    full_table_df <- tibble::add_column(details_df, program_df)
    
    tables <- tibble::add_row(full_table_df, tables) 
    
    message(glue::glue("Subject {i} of {total_size} scraped!"))
    }
    
  return(tables)
}

# Export final data frame to CSV
export_content <- function(content) {
  print(content)
  janitor::clean_names(content) |> 
  (\(df){
    clean_name <- stringr::str_remove_all(df$unidade_responsavel[1], "[:digit:]") |> 
      janitor::make_clean_names()   
    print(clean_name)
    write.csv(df, file = paste0(clean_name,".csv"), row.names = FALSE) 
    }
   )()
}

Enfim os dados

  • GitHub.

  • Todas as disciplinas dos Institutos, Faculdades e departamentos da UnB (>8000).

  • Escolhemos analisar: Instituto de Exatas, Faculdade de Tecnologia, Física, Química e Economia.

ChatGPT

Reinforcement learning

What is reinforcement learning?

  • Reinforcement learning é uma área de aprendizado de máquina que se preocupa com a forma como os agentes do software devem agir em um ambiente para maximizar a noção de recompensa cumulativa
  • Trata-se da introdução de um viés humano no modelo de linguagem

Processo de aprendizagem do GPT

  • O GPT usa a técnica de Reinforcement Learning from Human Feedback (RLHF) para minimizar saídas perigosas, falsas ou viesadas do modelo. É um processo em três fases:
    1. Modelo Supervised Fine-Tuning (SFT) [12-15k data points]
    2. Reward model (RM) [30-40k prompts]
    3. Fine-tuning do modelo SFT via Proximal Policy Optimization (PPO)

InstructGPT

Função objetivo da PPO

  • \[L^{CLIP}(\theta) = \mathbb{E}[min(r_t(\theta)A^t, clip(r_t(\theta), 1 - \epsilon, 1 + \epsilon)A^t)]\]

  • onde \(r_t(\theta) = \frac{\pi_\theta}{\pi_{\theta_{old}}}\), \(\pi\) se refere a uma política, \(A^t\) é um advantage estimator e \(\epsilon\) é um hiperparâmetro pequeno

  • A função clip garante que a razão entre as políticas não desvie significativamente do intervalo \([1 - \epsilon, 1 + \epsilon]\)

  • Uma política \(\pi(s)\) compreende as ações sugeridas que o agente deve realizar para cada estado possível \(s \in S\), seguindo um Markov decision process.

Proximal Policy Optimization (PPO)

  • O PPO é um algoritmo que otimiza a função de perda de uma política de aprendizagem por reforço usado na OpenAI desde 2017
  • O PPO busca um equilíbrio entre a facilidade de implementação, a complexidade da amostragem e a facilidade de ajuste
  • Tenta calcular uma atualização em cada etapa que minimize a função de custo e, ao mesmo tempo, garantindo que o desvio da política anterior seja relativamente pequeno.

Como conectou com a API

Código

# imports

from openai import OpenAI
import pandas as pd
import time
from tqdm import tqdm
from multiprocessing import Pool, cpu_count
import os
from wakepy import keep

# setup

api_key = open('api_key.txt').read().strip()
client = OpenAI(api_key=api_key)

obj_len = 150

prompt = f"""
Gostaria de usar o texto base para fazer uma descrição mais inteligível, direta, e bem explicada de no máximo {obj_len} palavras que vou chamar de Ementa Padronizada. 
Para que  você tenha um contexto, o texto que estou te fornecendo corresponde a uma disciplina de nível de graduação em uma universidade federal no brasil. 
Eu gostaria dessa ementa em um texto corrido e não em tópicos. 
Também gostaria que excluísse descrição de processos avaliativos usados e que se atenha a descrição do conteúdo que será ensinado de modo geral. 
Não é necessário detalhar como será dividido o conteúdo como módulos/unidade ou equivalentes, apenas a descrição do conteúdo abordado de forma geral. 
Peço também que não faça referencias ao fato de ser uma ementa padronizada, e retorne apenas o texto que voce produzir e nada mais.
""".strip().replace('\n', '')

def process_row(row):
    try:
        response = client.chat.completions.create(
            model="gpt-4",
            messages=[
                {"role": "system", "content": prompt},
                {"role": "user", "content": row['conteudo_completo']},
            ]
        ).choices[0].message.content
        return response
    except Exception as e:
        print(f"Error: {e}")
        return None

def init_worker():
    print(f"Initializing worker process {os.getpid()}")

def save_progress(ementas, filename='ementas.csv'):
    ementas.to_csv(filename, index=False)
    # print("Progress saved to disk.")

if __name__ == "__main__":
    ementas = pd.read_csv('ementas.csv')
    if 'conteudo_padronizado_gpt4' not in ementas.columns:
        ementas['conteudo_padronizado_gpt4'] = pd.Series(index=ementas.index, dtype=str)

    departments = [
        'INSTITUTO DE CIÊNCIAS EXATAS',
        'DEPTO ENGENHARIA FLORESTAL',
        'DEPTO ENGENHARIA DE PRODUCAO',
        'DEPTO ESTATÍSTICA',
        'DEPARTAMENTO DE MATEMÁTICA',
        'DEPTO ECONOMIA',
        'DEPTO CIÊNCIAS DA COMPUTAÇÃO',
        'DEPTO ENGENHARIA CIVIL E AMBIENTAL',
        'DEPTO ENGENHARIA ELETRICA',
        'INSTITUTO DE QUÍMICA',
        'INSTITUTO DE FÍSICA',
        'FACULDADE DE TECNOLOGIA'
    ]

    ementas = ementas[ementas['unidade_responsavel'].isin(departments)]

    pool_size = cpu_count()
    pool = Pool(pool_size, initializer=init_worker)

    missing_rows = ementas[ementas['conteudo_padronizado_gpt4'].isna()]

    with keep.running():
        for i, row in tqdm(missing_rows.iterrows(), total=len(missing_rows)):
            result = pool.apply_async(process_row, args=(row,))
            ementas.loc[i, 'conteudo_padronizado_gpt4'] = result.get()
            if i % 32 == 0:
                save_progress(ementas)

        save_progress(ementas)
        pool.close()
        pool.join()

O que usamos dos dados

  • Nome + ementa + descrição + conteúdo - > Minúscula, sem acentos/carac. especiais

  • Prompt usado

Gostaria de usar o texto base para fazer uma descrição mais inteligível, direta, e bem explicada de no máximo 150 palavras que vou chamar de Ementa Padronizada. Para que você tenha um contexto, o texto que estou te fornecendo corresponde a uma disciplina de nível de graduação em uma universidade federal no brasil. Eu gostaria dessa ementa em um texto corrido e não em tópicos. Também gostaria que excluísse descrição de processos avaliativos usados e que se atenha a descrição do conteúdo que será ensinado de modo geral. Não é necessário detalhar como será dividido o conteúdo como módulos/unidade ou equivalentes, apenas a descrição do conteúdo abordado de forma geral. Peço também que não faça referencias ao fato de ser uma ementa padronizada, e retorne apenas o texto para que seja fácil copiá-lo.

VIGILANCIA SANITARIA, DEONTOLOGIA E LEGISLACAO FARMACEUTICA

Esta disciplina de nível de graduação em farmácia foca na vigilância sanitária, deontologia e legislação farmacêutica. O curso visa introduzir o estudante à legislação atual que rege a produção, comercialização, prescrição, informação e dispensação de medicamentos. Também é abordada a legislação do sistema de saúde e da vigilância sanitária, além de se destacar os aspectos éticos da profissão farmacêutica.

O conteúdo inclui uma exploração da história da profissão farmacêutica, a evolução do conceito de ética profissional, e as regulamentações que influenciam a prática farmacêutica. Os alunos são incentivados a desenvolver uma reflexão crítica sobre os dilemas éticos da profissão. O curso também proporciona um entendimento sobre vigilância sanitária, incluindo seu papel no sistema de saúde, o processo de registro de medicamentos, e as práticas relacionadas à informação e propaganda de medicamentos.

Além disso, o curso abrange temas como práticas de produção e inspeção farmacêutica, a defesa do consumidor em relação a medicamentos, e o controle de qualidade laboratorial dentro do contexto da vigilância sanitária. O objetivo é preparar os alunos para compreender e aplicar as leis e regulamentos do campo farmacêutico, fomentando uma prática ética e responsável.

Embeding

O “embedding” é uma técnica em aprendizado de máquina que transforma dados complexos e de alta dimensão, como textos ou imagens, em vetores de baixa dimensão, preservando as relações semânticas e contextuais.

API OpenAI

A API da OpenAI foi utilizada para fazer os prompts de padronização dos textos e geração dos embeddings usando ADA version-002

import openai
response = openai.Embedding.create(
  input="porcine pals say",
  model="text-embedding-ada-002"
)

print(response)
{
  "data": [
    {
      "embedding": [
        -0.0108,
        -0.0107,
        0.0323,
        ...
        -0.0114
      ],
      "index": 0,
      "object": "embedding"
    }
  ],
  "model": "text-embedding-ada-002",
  "object": "list"
}
  • Output dos Embedings: Vetor de dimensão 1536
  • Contexto mais longo. O comprimento do contexto do novo modelo é aumentado por um fator de quatro, de 2048 para 8192, tornando-o mais conveniente para trabalhar com documentos extensos.

K-means:

O k-Means é um algoritmo de agrupamento que divide dados em ( k ) grupos, minimizando a variação interna e ajustando os centróides de cada grupo iterativamente até a convergência.

t-SNE

O t-SNE (t-distributed stochastic neighbor embedding) é uma técnica de abordagem não-linear de redução de dimensionalidade, focado na preservação das semelhanças locais, ideal para visualizar agrupamentos em duas ou três dimensões ( Geoffrey Hinton & Sam Roweis). Variação usando a t-Student por Laurens van der Maaten.

Clusterização

Resultados fodásticos

import numpy as np
import pandas as pd
import plotly as plt
from ast import literal_eval
from sklearn.cluster import KMeans
from sklearn.manifold import TSNE

df = pd.read_csv('ementas.csv')
df["embeddings_ada"] = df.embeddings_ada.apply(literal_eval).apply(np.array)
matrix = np.vstack(df.embeddings_ada.values)
matrix.shape

n_clusters = len(df['unidade_responsavel'].unique())

kmeans = KMeans(n_clusters=n_clusters, init="k-means++", random_state=42)
kmeans.fit(matrix)
labels = kmeans.labels_
df["Cluster"] = labels


cluster_names = []
for i in range(n_clusters):
  print(f'Cluster {i}')
  names = ' '.join(df[df['Cluster'] == i]['nome'].to_list())
  print(len(names))
  cluster_names.append(names)
  print(names)
  
  generated_names = [
    'Ciências Físicas Avançadas',
    'Economia e Política Econômica',
    'Estatística e Métodos Quantitativos',
    'Ciência da Computação e Sistemas',
    'Engenharia Civil e Infraestrutura',
    'Matemática Avançada e Aplicada',
    'Gestão e Projeto Interdisciplinar',
    'Engenharia de Redes e Telecomunicações',
    'Gestão Ambiental e Sustentabilidade',
    'Química Teórica e Aplicada',
    'Estágio Supervisionado e Regência',
    'Engenharia Elétrica e Eletrônica'
    ]
    
fig, ax = plt.subplots(figsize=(15, 5))
    
    tsne = TSNE(n_components=2, perplexity=15, random_state=42, init="random", learning_rate=200)
    vis_dims2 = tsne.fit_transform(matrix)
    
    x = [x for x, y in vis_dims2]
    y = [y for x, y in vis_dims2]
    
    deps = df['unidade_responsavel'].unique().tolist()
    
    for category, color in enumerate([plt.get_cmap("tab20")(i) for i in range(n_clusters)]):
      xs = np.array(x)[df.unidade_responsavel == deps[category]]
      ys = np.array(y)[df.unidade_responsavel == deps[category]]
      ax.scatter(xs, ys, color=color, alpha=0.2)
      
      avg_x = xs.mean()
      avg_y = ys.mean()
      
      ax.annotate(
        deps[category],
        (avg_x, avg_y),
        horizontalalignment='center',
        verticalalignment='center',
        size=10,
        weight='bold',
        color=color,
        alpha=1
        )
        ax.set_title("Visualização do Embedding dos Departamentos usando t-SNE")
        plt.show()

t-SNE Departamentos

t-SNE Departamentos

t-SNE Estatística

t-SNE Economia

Protótipo

import pandas as pd
import numpy as np
from ast import literal_eval
from sklearn.metrics.pairwise import cosine_similarity
from openai import OpenAI
import gradio as gr

api_key = open('api_key.txt').read().strip()
client = OpenAI(api_key=api_key)

df = pd.read_csv('ementas.csv')
df["embeddings_ada"] = df.embeddings_ada.apply(literal_eval).apply(np.array)

def get_embedding(text, model="text-embedding-ada-002"):
    text = text.replace("\n", " ")
    return client.embeddings.create(input=[text], model=model).data[0].embedding

def get_recommendations(text):
    text_embedding = np.array(get_embedding(text)).reshape(1, -1)
    similarity = df['embeddings_ada'].apply(lambda x: cosine_similarity(x.reshape(1, -1), text_embedding.reshape(1, -1)).item())
    similarity = similarity.sort_values(ascending=False).head(10)
    similarity = df.iloc[similarity.index].drop_duplicates(subset=['nome']).drop(columns=['conteudo_completo', 'embeddings_ada'])
    similarity.index = range(1, len(similarity)+1)
    return similarity

def recommend(text):
    try:
        recommendations = get_recommendations(text)
        # Convert the DataFrame to HTML for rendering
        return recommendations.to_html(escape=False)
    except Exception as e:
        return str(e)

with gr.Blocks() as demo:
    gr.Markdown("## Course Recommendation System")
    gr.Markdown("Entre suas preferências de acadêmicas e nós te recomendaremos os 10 cursos mais similares da área de exatas + engenharias.")
    
    with gr.Row():
        text_input = gr.Textbox(lines=2, placeholder="Enter Description Here", label="Descreva seus interesses acadêmicos")
    
    with gr.Row():
        submit_button = gr.Button("Submit")

    output = gr.HTML()

    submit_button.click(recommend, inputs=text_input, outputs=output)

demo.launch()

Conclusões e Recomendações futuras

  • É difícil conseguir os dados da UnB.
  • Dá pra fazer WebScraping no R
  • A API da OpenAI é ótima (e cara!)
    • Da pra usar outros modelos além do ChatGPT
  • Foi possível criar clusters relativamente coesos.
  • Analisar quais as disciplinas “distoantes”.
  • Dashboard para a consulta por parte dos alunos.
  • Análise mais formal de sobreposição entre cursos ou falta de coesão no currículo.

Obrigada!!!